page.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. "use client";
  2. import { useEffect, useRef, useCallback, useState } from "react";
  3. import { useSession } from "next-auth/react";
  4. import { useParams, redirect, useRouter } from "next/navigation";
  5. import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
  6. import { Button } from "@/components/ui/button";
  7. import {
  8. Dialog,
  9. DialogContent,
  10. DialogDescription,
  11. DialogFooter,
  12. DialogHeader,
  13. DialogTitle,
  14. } from "@/components/ui/dialog";
  15. import { Loader2, Video, AlertTriangle, FileText, Clock, XCircle } from "lucide-react";
  16. import { ConsultationNotes } from "@/components/appointments/ConsultationNotes";
  17. import RecordsModal from "@/components/records/RecordsModal";
  18. import type { Record as MedicalRecord } from "@/components/records/types";
  19. import type { Appointment } from "@/types/appointments";
  20. import { canJoinMeeting, getAppointmentTimeStatus } from "@/utils/appointments";
  21. interface JitsiMeetExternalAPI {
  22. dispose: () => void;
  23. addEventListener: (event: string, handler: () => void) => void;
  24. }
  25. declare global {
  26. interface Window {
  27. JitsiMeetExternalAPI: new (domain: string, options: Record<string, unknown>) => JitsiMeetExternalAPI;
  28. }
  29. }
  30. export default function MeetPage() {
  31. const router = useRouter();
  32. const { data: session, status } = useSession();
  33. const params = useParams();
  34. const jitsiContainer = useRef<HTMLDivElement>(null);
  35. const jitsiApi = useRef<JitsiMeetExternalAPI | null>(null);
  36. const isInitialized = useRef(false);
  37. const isLeavingIntentionally = useRef(false);
  38. const [showExitDialog, setShowExitDialog] = useState(false);
  39. const [showRecordsModal, setShowRecordsModal] = useState(false);
  40. const [appointment, setAppointment] = useState<Appointment | null>(null);
  41. const [loading, setLoading] = useState(true);
  42. const [accessDenied, setAccessDenied] = useState(false);
  43. const [denialReason, setDenialReason] = useState("");
  44. const [jitsiToken, setJitsiToken] = useState<string | null>(null);
  45. const [jitsiDomain, setJitsiDomain] = useState<string>("");
  46. const [jitsiRoomName, setJitsiRoomName] = useState<string>("");
  47. const [useJWT, setUseJWT] = useState<boolean>(false);
  48. // Cargar información del appointment y JWT token
  49. useEffect(() => {
  50. const loadAppointment = async () => {
  51. try {
  52. const response = await fetch(`/api/appointments/${params.id}`);
  53. if (response.ok) {
  54. const data = await response.json();
  55. setAppointment(data);
  56. // Validar acceso por tiempo
  57. const timeCheck = canJoinMeeting(data.fechaSolicitada);
  58. if (!timeCheck.canJoin) {
  59. setAccessDenied(true);
  60. setDenialReason(timeCheck.reason || "No puedes acceder a esta videollamada");
  61. setLoading(false);
  62. return;
  63. }
  64. // Validar que la cita esté aprobada
  65. if (data.estado !== "APROBADA" && data.estado !== "COMPLETADA") {
  66. setAccessDenied(true);
  67. setDenialReason("Esta cita no está aprobada");
  68. setLoading(false);
  69. return;
  70. }
  71. // Obtener JWT token para Jitsi
  72. const tokenResponse = await fetch(`/api/appointments/${params.id}/jitsi-token`);
  73. if (tokenResponse.ok) {
  74. const tokenData = await tokenResponse.json();
  75. setJitsiToken(tokenData.token || null);
  76. setJitsiDomain(tokenData.domain || "");
  77. setJitsiRoomName(tokenData.roomName || `appointment-${params.id}`);
  78. setUseJWT(tokenData.useJWT || false);
  79. } else {
  80. console.warn("No se pudo obtener JWT token, usando configuración por defecto");
  81. setJitsiRoomName(`appointment-${params.id}`);
  82. // Si falla, intentar obtener el dominio del response aunque sea error
  83. const errorData = await tokenResponse.json().catch(() => ({}));
  84. if (errorData.domain) {
  85. setJitsiDomain(errorData.domain);
  86. }
  87. }
  88. } else {
  89. setAccessDenied(true);
  90. setDenialReason("No se pudo cargar la información de la cita");
  91. }
  92. } catch (error) {
  93. console.error("Error loading appointment:", error);
  94. setAccessDenied(true);
  95. setDenialReason("Error al cargar la cita");
  96. } finally {
  97. setLoading(false);
  98. }
  99. };
  100. if (params.id) {
  101. loadAppointment();
  102. }
  103. }, [params.id]);
  104. const handleCopyContent = (content: string) => {
  105. navigator.clipboard.writeText(content);
  106. // TODO: Add notification
  107. };
  108. const handleDownloadReport = (record: MedicalRecord) => {
  109. const blob = new Blob([record.content], { type: "text/plain" });
  110. const url = URL.createObjectURL(blob);
  111. const a = document.createElement("a");
  112. a.href = url;
  113. a.download = `reporte-medico-${record.id.slice(-8)}-${new Date().toISOString().split("T")[0]}.txt`;
  114. document.body.appendChild(a);
  115. a.click();
  116. document.body.removeChild(a);
  117. URL.revokeObjectURL(url);
  118. // TODO: Add notification
  119. };
  120. const handleGeneratePDF = async (_record: MedicalRecord) => {
  121. // TODO: Implement PDF generation
  122. };
  123. const initJitsi = useCallback(() => {
  124. if (!jitsiContainer.current || !session || isInitialized.current || !jitsiRoomName) return;
  125. isInitialized.current = true;
  126. const options: Record<string, unknown> = {
  127. roomName: jitsiRoomName,
  128. width: "100%",
  129. height: 600,
  130. parentNode: jitsiContainer.current,
  131. configOverwrite: {
  132. startWithAudioMuted: false,
  133. startWithVideoMuted: false,
  134. prejoinPageEnabled: false,
  135. },
  136. interfaceConfigOverwrite: {
  137. TOOLBAR_BUTTONS: [
  138. "microphone",
  139. "camera",
  140. "closedcaptions",
  141. "desktop",
  142. "fullscreen",
  143. "fodeviceselection",
  144. "hangup",
  145. "chat",
  146. "settings",
  147. "videoquality",
  148. "filmstrip",
  149. "tileview",
  150. ],
  151. SHOW_JITSI_WATERMARK: false,
  152. SHOW_WATERMARK_FOR_GUESTS: false,
  153. },
  154. userInfo: {
  155. displayName: `${session.user?.name || "Usuario"} ${session.user?.lastname || ""}`.trim(),
  156. email: session.user?.email || undefined,
  157. },
  158. };
  159. // Si se usa JWT, agregar el token
  160. if (useJWT && jitsiToken) {
  161. options.jwt = jitsiToken;
  162. }
  163. jitsiApi.current = new window.JitsiMeetExternalAPI(jitsiDomain, options);
  164. // Event listeners - Solo redirigir si el usuario salió desde Jitsi directamente
  165. jitsiApi.current.addEventListener("videoConferenceLeft", () => {
  166. // Dar un pequeño delay para que el beforeunload se procese
  167. setTimeout(() => {
  168. if (isLeavingIntentionally.current) {
  169. router.push("/appointments");
  170. }
  171. }, 100);
  172. });
  173. jitsiApi.current.addEventListener("readyToClose", () => {
  174. setTimeout(() => {
  175. if (isLeavingIntentionally.current) {
  176. router.push("/appointments");
  177. }
  178. }, 100);
  179. });
  180. }, [session, jitsiRoomName, jitsiDomain, jitsiToken, useJWT, router]);
  181. const handleExitClick = () => {
  182. setShowExitDialog(true);
  183. };
  184. const handleConfirmExit = () => {
  185. isLeavingIntentionally.current = true;
  186. if (jitsiApi.current) {
  187. jitsiApi.current.dispose();
  188. jitsiApi.current = null;
  189. }
  190. isInitialized.current = false;
  191. router.push("/appointments");
  192. };
  193. const handleCancelExit = () => {
  194. setShowExitDialog(false);
  195. };
  196. // Interceptar cierre de pestaña o navegación
  197. useEffect(() => {
  198. const handleBeforeUnload = (e: BeforeUnloadEvent) => {
  199. // Solo mostrar advertencia si NO es una salida intencional
  200. if (!isLeavingIntentionally.current) {
  201. e.preventDefault();
  202. e.returnValue = "¿Estás seguro de que quieres salir de la videollamada?";
  203. return e.returnValue;
  204. }
  205. };
  206. window.addEventListener("beforeunload", handleBeforeUnload);
  207. return () => {
  208. window.removeEventListener("beforeunload", handleBeforeUnload);
  209. };
  210. }, []);
  211. useEffect(() => {
  212. if (status === "loading" || !session || !jitsiContainer.current || isInitialized.current || !jitsiDomain || !jitsiRoomName) return;
  213. // Construir la URL del script usando el dominio configurado
  214. const scriptSrc = `https://${jitsiDomain}/external_api.js`;
  215. // Verificar si el script ya está cargado
  216. const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
  217. if (existingScript) {
  218. // Si el script ya existe y window.JitsiMeetExternalAPI está disponible, inicializar directamente
  219. if (window.JitsiMeetExternalAPI) {
  220. initJitsi();
  221. }
  222. return;
  223. }
  224. // Cargar Jitsi script desde el dominio configurado
  225. const script = document.createElement("script");
  226. script.src = scriptSrc;
  227. script.async = true;
  228. script.onload = () => initJitsi();
  229. script.onerror = () => {
  230. console.error(`Error al cargar el script de Jitsi desde ${scriptSrc}`);
  231. setAccessDenied(true);
  232. setDenialReason("No se pudo conectar con el servidor de videollamadas");
  233. };
  234. document.body.appendChild(script);
  235. return () => {
  236. if (jitsiApi.current) {
  237. jitsiApi.current.dispose();
  238. jitsiApi.current = null;
  239. }
  240. isInitialized.current = false;
  241. // No eliminar el script aquí para evitar conflictos
  242. };
  243. }, [status, session, jitsiDomain, jitsiRoomName, initJitsi]);
  244. if (status === "loading" || loading) {
  245. return (
  246. <div className="flex items-center justify-center min-h-screen">
  247. <Loader2 className="h-8 w-8 animate-spin" />
  248. </div>
  249. );
  250. }
  251. if (!session) {
  252. redirect("/auth/login");
  253. }
  254. // Mostrar pantalla de acceso denegado si no cumple las condiciones
  255. if (accessDenied) {
  256. return (
  257. <div className="container mx-auto px-4 py-8 max-w-2xl">
  258. <Card>
  259. <CardHeader>
  260. <div className="flex items-center gap-2 text-destructive">
  261. <XCircle className="h-6 w-6" />
  262. <CardTitle>Acceso no permitido</CardTitle>
  263. </div>
  264. </CardHeader>
  265. <CardContent className="space-y-4">
  266. <p className="text-muted-foreground">{denialReason}</p>
  267. {appointment?.fechaSolicitada && (
  268. <div className="bg-muted p-4 rounded-lg">
  269. <div className="flex items-center gap-2 mb-2">
  270. <Clock className="h-5 w-5" />
  271. <p className="font-medium">Estado de la cita</p>
  272. </div>
  273. <p className="text-sm text-muted-foreground">
  274. {getAppointmentTimeStatus(appointment.fechaSolicitada)}
  275. </p>
  276. </div>
  277. )}
  278. <div className="flex gap-2">
  279. <Button onClick={() => router.push(`/appointments/${params.id}`)}>
  280. Ver detalles de la cita
  281. </Button>
  282. <Button variant="outline" onClick={() => router.push("/appointments")}>
  283. Volver a mis citas
  284. </Button>
  285. </div>
  286. </CardContent>
  287. </Card>
  288. </div>
  289. );
  290. }
  291. const isDoctor = session.user.role === "DOCTOR";
  292. const appointmentId = params.id as string;
  293. return (
  294. <>
  295. <div className="container mx-auto px-4 py-8 max-w-7xl">
  296. <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
  297. {/* Videollamada - 2 columnas en pantallas grandes */}
  298. <div className="lg:col-span-2">
  299. <Card>
  300. <CardHeader>
  301. <div className="flex items-center justify-between">
  302. <div className="flex items-center gap-2">
  303. <Video className="h-6 w-6" />
  304. <CardTitle>Consulta Telemática</CardTitle>
  305. </div>
  306. <div className="flex items-center gap-2">
  307. {appointment?.record && (
  308. <Button
  309. variant="outline"
  310. size="sm"
  311. onClick={() => setShowRecordsModal(true)}
  312. >
  313. <FileText className="h-4 w-4 mr-2" />
  314. Ver Reporte
  315. </Button>
  316. )}
  317. <Button variant="outline" onClick={handleExitClick}>
  318. Salir
  319. </Button>
  320. </div>
  321. </div>
  322. </CardHeader>
  323. <CardContent>
  324. <div ref={jitsiContainer} className="w-full rounded-lg overflow-hidden bg-muted" />
  325. </CardContent>
  326. </Card>
  327. </div>
  328. {/* Notas de consulta - 1 columna en pantallas grandes */}
  329. <div className="lg:col-span-1">
  330. <ConsultationNotes appointmentId={appointmentId} isDoctor={isDoctor} />
  331. </div>
  332. </div>
  333. </div>
  334. {/* Records Modal */}
  335. <RecordsModal
  336. isOpen={showRecordsModal}
  337. record={appointment?.record as MedicalRecord || null}
  338. generatingPDF={false}
  339. onClose={() => setShowRecordsModal(false)}
  340. onCopyContent={handleCopyContent}
  341. onDownloadReport={handleDownloadReport}
  342. onGeneratePDF={handleGeneratePDF}
  343. />
  344. {/* Modal de confirmación de salida */}
  345. <Dialog open={showExitDialog} onOpenChange={setShowExitDialog}>
  346. <DialogContent>
  347. <DialogHeader>
  348. <div className="flex items-center gap-2">
  349. <AlertTriangle className="h-5 w-5 text-destructive" />
  350. <DialogTitle>¿Salir de la videollamada?</DialogTitle>
  351. </div>
  352. <DialogDescription>
  353. Si sales ahora, la videollamada se cerrará. {isDoctor && "Asegúrate de haber guardado las notas de consulta si las tienes."}
  354. </DialogDescription>
  355. </DialogHeader>
  356. <DialogFooter>
  357. <Button variant="outline" onClick={handleCancelExit}>
  358. Cancelar
  359. </Button>
  360. <Button variant="destructive" onClick={handleConfirmExit}>
  361. Salir de la llamada
  362. </Button>
  363. </DialogFooter>
  364. </DialogContent>
  365. </Dialog>
  366. </>
  367. );
  368. }